iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 16
1

本篇延續Day15 MVVM專案-2 Simple Fragment -3 DI

今天會用Data Binding, ViewModel, LiveData 構築一個RecyclerView
內含新增,移除,編輯等基本功能
成品圖
https://ithelp.ithome.com.tw/upload/images/20191001/20120279s2v29rXhBr.png

資料部分

S03DataRepository.kt

interface S03DataRepository<T> {
    val list: List<T>

    fun add(item: T)
    fun removeAt(idx: Int)
}

S03Memo.kt

data class S03Memo(var content: String)

S03MemoRepository.kt

class S03MemoRepository : S03DataRepository<S03Memo> {

    private val memos by lazy {
        "The Data Binding Library is a support library that allows you to bind UI components in your layouts to data sources in your app using a declarative format rather than programmatically."
                .split(' ')
                .map(::S03Memo)
                .toMutableList()
    }

    override val list: List<S03Memo> by lazy { memos }

    override fun add(item: S03Memo) {
        memos.add(0, item)
    }

    override fun removeAt(idx: Int) {
        memos.removeAt(idx)
    }

}

viewmodel

S03ViewModel.kt

class S03ViewModel(private val repository: S03DataRepository<S03Memo> = S03MemoRepository()) :
    ViewModel() {
    sealed class ListAction {
        class Added : ListAction()
        class Removed(val memo: S03Memo, val idx: Int) : ListAction()
    }

    val memos: LiveData<List<S03Memo>> = MutableLiveData<List<S03Memo>>().apply {
        value = repository.list
    }

    val listAction: MutableLiveData<ListAction> = MutableLiveData()
    val newContent: MutableLiveData<String> = MutableLiveData()
    val sizeOfMemos: Int = memos.value!!.size

    fun remove(memo: List<S03Memo>, idx: Int) {
        if (idx < 0) return
        repository.removeAt(idx)
        listAction.value = ListAction.Removed(memo[idx], idx)
    }

    fun add() {
        if (newContent.value == null || newContent.value.equals("")) return
        repository.add(S03Memo(newContent.value ?: ""))
        newContent.value = ""
        listAction.value = ListAction.Added()
    }
}

S03MemoAdapter.kt

typealias OnListItemEvent = (List<S03Memo>, Int) -> Unit

class S03MemoAdapter(private val items: List<S03Memo>, val onClickRemove: OnListItemEvent) : androidx.recyclerview.widget.RecyclerView.Adapter<S03MemoAdapter.VH>() {

    inner class VH(view: View) : androidx.recyclerview.widget.RecyclerView.ViewHolder(view) {
        val content = MutableLiveData<String>()
        
        fun onClickRemove(): Unit = this@S03MemoAdapter.onClickRemove(items, layoutPosition)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        val lifecycleOwner = parent.context as LifecycleOwner
        val binding = DataBindingUtil.inflate<ListitemS03Binding>(LayoutInflater.from(parent.context), R.layout.listitem_s03, parent, false)
        val vh = VH(binding.root)

        binding.let {
            it.setLifecycleOwner(lifecycleOwner)
            it.vh = vh
        }

        vh.content.observe(lifecycleOwner, Observer {
            val memo = items[vh.layoutPosition]
            memo.content = it ?: ""
        })
        return vh
    }

    override fun getItemCount(): Int = items.size

    override fun onBindViewHolder(holder: VH, position: Int): Unit = with(holder) {
        val memo = items[position]
        content.value = memo.content
    }

}

Stage03Activity.kt


class Stage03Activity : AppCompatActivity() {

    private val viewModel: S03ViewModel by lazy { ViewModelProviders.of(this).get(S03ViewModel::class.java) }
    private val adapter: androidx.recyclerview.widget.RecyclerView.Adapter<S03MemoAdapter.VH> by lazy { S03MemoAdapter(viewModel.memos.value!!, viewModel::remove) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        DataBindingUtil.setContentView<ActivityStage03Binding>(this, R.layout.activity_stage03).let {
            it.setLifecycleOwner(this)
            it.viewModel = viewModel
        }

        viewModel.listAction.observe(this, Observer {
            when(it) {
                is S03ViewModel.ListAction.Added -> onAdded()
                is S03ViewModel.ListAction.Removed -> onRemoved(it.memo, it.idx)
            }
        })

        rcv_contents.let {
            it.adapter = adapter
            it.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this)
        }
    }

    private fun onAdded() {
        adapter.notifyItemInserted(0)
        adapter.notifyItemRangeChanged(1, viewModel.sizeOfMemos -1)
        rcv_contents.post {
            rcv_contents.scrollToPosition(0)
        }
    }

    private fun onRemoved(memo: S03Memo, idx: Int) {
        adapter.notifyItemRemoved(idx)
        adapter.notifyItemRangeChanged(idx, viewModel.sizeOfMemos - idx)
        Toast.makeText(this, "\"${memo.content}\" is removed", Toast.LENGTH_SHORT).show()
    }
}

xml

activity_stage03.xml

<?xml version="1.0" encoding="utf-8"?>

<layout xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
                name="viewModel"
                type="com.ithome11.jetpackmvvmdemo.main.s03.S03ViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:context=".e03.E03Activity">

        <androidx.appcompat.widget.AppCompatImageButton
                android:id="@+id/bt_add"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginEnd="8dp"
                android:layout_marginRight="8dp"
                android:layout_marginTop="8dp"
                android:background="@null"
                android:src="@android:drawable/ic_menu_add"
                android:onClick="@{_ -> viewModel.add()}"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

        <androidx.appcompat.widget.AppCompatEditText
                android:id="@+id/et_content_new"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginLeft="8dp"
                android:layout_marginStart="8dp"
                android:layout_marginTop="8dp"
                android:imeOptions="actionNext"
                android:lines="1"
                android:singleLine="true"
                android:text="@={viewModel.newContent}"
                app:layout_constraintEnd_toStartOf="@+id/bt_add"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

        <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/rcv_contents"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:layout_marginTop="8dp"
                tools:listitem="@layout/listitem_s03"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/et_content_new" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

listitem_s03.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">
    
    <data>
        <variable
            name="vh"
            type="com.ithome11.jetpackmvvmdemo.main.s03.S03MemoAdapter.VH" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <androidx.appcompat.widget.AppCompatImageButton
            android:id="@+id/bt_remove"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="8dp"
            android:layout_marginRight="8dp"
            android:layout_marginTop="8dp"
            android:onClick="@{_ -> vh.onClickRemove()}"
            android:background="@null"
            android:src="@android:drawable/ic_menu_delete"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <androidx.appcompat.widget.AppCompatEditText
            android:id="@+id/et_content"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginLeft="8dp"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:lines="1"
            android:singleLine="true"
            android:imeOptions="actionGo"
            android:text="@={vh.content}"
            app:layout_constraintEnd_toStartOf="@id/bt_remove"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

至此專案便可正常運行

接著新增測試案例

build.gradle(module:app)

dependencies {
    androidTestImplementation 'com.android.support.test.espresso:espresso-contrib:3.2.0'
}

ViewActionsEx.kt

object ViewActionsEx {
    @JvmStatic
    fun setSpeed(speed: Float) = object : ViewAction {
        override fun getDescription(): String = "set animation speed to : $speed"

        override fun getConstraints(): Matcher<View> = ViewMatchers.isAssignableFrom(LottieAnimationView::class.java)

        override fun perform(uiController: UiController, view: View) {
            (view as LottieAnimationView).speed = 10.0f
        }
    }

    @JvmStatic
    fun waiting(milliSec: Long) = object : ViewAction {
        override fun getDescription(): String = "waiting $milliSec milli seconds"

        override fun getConstraints(): Matcher<View> = ViewMatchers.isDisplayed()

        override fun perform(uiController: UiController, view: View) =
                uiController.loopMainThreadForAtLeast(milliSec)
    }

    @JvmStatic
    fun setProgress(value: Int) = object : ViewAction {
        override fun getDescription(): String = "set progress to $value"

        override fun getConstraints(): Matcher<View> = ViewMatchers.isAssignableFrom(ProgressBar::class.java)

        override fun perform(uiController: UiController, view: View) {
            (view as ProgressBar).progress = value
        }
    }

}

Stage03ActivityTest.kt

@RunWith(AndroidJUnit4::class)
class Stage03ActivityTest {
    @get:Rule
    val rule = object : ActivityTestRule<Stage03Activity>(Stage03Activity::class.java) {}

    @Test
    fun add() {
        // given
        val givenNewContent = "new content"

        // when
        Espresso.onView(withId(R.id.et_content_new)).perform(click(), replaceText(givenNewContent))
        Espresso.onView(withId(R.id.bt_add)).perform(click())

        // then
        Espresso.onView(withId(R.id.rcv_contents))
                .perform(RecyclerViewActions.actionOnItemAtPosition<S03MemoAdapter.VH>(0
                        , object : ViewAction {
                    override fun getDescription(): String = "check added memo content is \"$givenNewContent\""

                    override fun getConstraints(): Matcher<View> = hasDescendant(withText(givenNewContent))

                    override fun perform(uiController: UiController?, view: View?) {
                        @Suppress("UNCHECKED_CAST")
                        val matcher = constraints as Matcher<in View?>
                        Assert.assertThat(view, matcher)
                    }

                }))
    }


    @Test
    fun remove() {
        // given
        val givenIdxWillBeRemoved = 3
        var beforeContent = ""

        Espresso.onView(withId(R.id.rcv_contents))
                .perform(RecyclerViewActions.actionOnItemAtPosition<S03MemoAdapter.VH>(givenIdxWillBeRemoved
                        , object : ViewAction {
                    override fun getDescription(): String = "get before content"

                    override fun getConstraints(): Matcher<View> = isDisplayed()

                    override fun perform(uiController: UiController, view: View) {
                        beforeContent = view.findViewById<TextView>(R.id.et_content).text.toString()
                    }
                }))

        // when
        Espresso.onView(withId(R.id.rcv_contents))
                .perform(RecyclerViewActions.actionOnItemAtPosition<S03MemoAdapter.VH>(givenIdxWillBeRemoved
                        , object : ViewAction {
                    override fun getDescription(): String = "click remove"

                    override fun getConstraints(): Matcher<View> = isDisplayed()

                    override fun perform(uiController: UiController, view: View) {
                        view.findViewById<View>(R.id.bt_remove).performClick()
                        uiController.loopMainThreadForAtLeast(ViewConfiguration.getTapTimeout().toLong())
                    }
                }))


        // then
        Espresso.onView(withId(R.id.rcv_contents))
                .perform(RecyclerViewActions.actionOnItemAtPosition<S03MemoAdapter.VH>(givenIdxWillBeRemoved
                        , object : ViewAction {
                    override fun getDescription(): String = "check remove"

                    override fun getConstraints(): Matcher<View> = isDisplayed()

                    override fun perform(uiController: UiController, view: View) {
                        val content = view.findViewById<TextView>(R.id.et_content).text.toString()
                        Assert.assertFalse(content == beforeContent)
                    }
                }))
    }


    @Test
    fun edit() {
        // given
        val givenIdxWillBeEdit = 3
        val givenReplaceText = "replaced!!"


        // when
        Espresso.onView(withId(R.id.rcv_contents))
                .perform(
                    RecyclerViewActions.actionOnItemAtPosition<S03MemoAdapter.VH>(givenIdxWillBeEdit
                        , object : ViewAction {
                    override fun getDescription(): String = "get before content"

                    override fun getConstraints(): Matcher<View> = isDisplayed()

                    override fun perform(uiController: UiController, view: View) {
                        view.findViewById<EditText>(R.id.et_content).setText(givenReplaceText)
                    }
                }))


        Espresso.onView(withId(R.id.rcv_contents))  // scrolling
                .perform(
                    ViewActionsEx.waiting(500)
                        , RecyclerViewActions.scrollToPosition<S03MemoAdapter.VH>(15)
                        , ViewActionsEx.waiting(500)
                        , RecyclerViewActions.scrollToPosition<S03MemoAdapter.VH>(0)
                        , ViewActionsEx.waiting(500)
                )


        // then
        Espresso.onView(withId(R.id.rcv_contents))
                .perform(RecyclerViewActions.actionOnItemAtPosition<S03MemoAdapter.VH>(givenIdxWillBeEdit
                        , object : ViewAction {
                    override fun getDescription(): String = "check replaced text"

                    override fun getConstraints(): Matcher<View> = isDisplayed()

                    override fun perform(uiController: UiController, view: View) {
                        val content = view.findViewById<TextView>(R.id.et_content).text.toString()
                        Assert.assertEquals(givenReplaceText, content)
                    }
                }))

    }


}

soluction
https://github.com/mars1120/jetpackMvvmDemo/tree/mvvm-03-RecyclerView


上一篇
Day15 MVVM專案-2 Simple Fragment -3 DI
下一篇
Day17 MVVM專案- 補單元測試
系列文
Android × CI/CD 如何用基本的MVVM專案實現 CI/CD 30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言